SurfaceView+MediaPlayer封装之路
作者 | jiashuai94
地址 | https://github.com/shuaijia/JsPlayer
博客 | http://blog.csdn.net/jiashuai94
声明 | 本文是 jiashuai94 原创,已获授权发布,未经原作者允许请勿转载
我的播放器叫做JsPlayer,喜欢的话,就给个star喽
这里我只介绍播放器封装思路,会贴出部分代码,如果大家想查看完整代码,可以去Github查看,有不清楚或错误或改进的地方,可以issues 我!
先上效果图:(1.5版本新增弹幕功能)
关于更多SurfaceView的介绍,可参考我写的另一片文章:http://blog.csdn.net/jiashuai94/article/details/77882644
播放器结构
UML图
已经对SurfaceView+MediaPlayer封装视屏播放器有了大致的了解,接下来就开始视屏播放
器的封装之旅吧!
一.工具类
工欲善其事,必先利其器!
想封装结构清晰,使用方便的视频播放器,工具类是少不了的!JsPlayer主要用了以下几个工具类:
DisplayUtils
NetworkUtils
StringUtils
DisplayUtils:负责界面展示相关工具,例如px、dp、sp的相互转换;获取屏幕宽高度;
切换横屏、竖屏等;
NetworkUtils:判断手机是否联网;是否为wifi;是否是流量;网络状态等;
StringUtils:主要将long型毫秒转换为时间格式的字符串。
代码就不贴了,很简单。大家想了解,去github中查看吧。
二.实体类
为了在使用视频播放器时规范传入的数据,同时也方便使用者调用和封装,故定义了视频详情的接口:其包含两个抽象方法,分别返回视频地址和视频标题。
/** * 视频数据类 * 请实现本接口 */ public interface IVideoInfo extends Serializable { /** * 视频标题 */ String getVideoTitle(); /** * 视频播放路径(本地或网络) */ String getVideoPath(); }
用户可根据项目实际情况对其进行扩展(需实现此接口即可),比如默认图地址,点赞数,是否购买,弹幕信息等等。但视频标题和视频地址必须返回!
三.回调相关
大家都知道,VideoView或其他视频播放器在使用时,有准备好监听、播放完成监听、错误监听等等,可供开发者在对应情况进行对应处理;而且我们有时也需要在用户点击播放暂停、全屏、拖动进度条等情况下获得操作回调。因此,我们封装了两个回调接口:
OnVideoControlListener:视频控制回调
OnPlayerCallback:视频状态回调
/** * 视频控制监听 */ public interface OnVideoControlListener { /** * 开始播放按钮 */ void onStartPlay(); /** * 返回 */ void onBack(); /** * 全屏 */ void onFullScreen(); /** * 错误后的重试 */ void onRetry(int errorStatus); }
/** * 视频操作回调,是将系统MediaPlayer的常见回调封装 */ public interface OnPlayerCallback { /** * 准备好 */ void onPrepared(MediaPlayer mp); /** * 视频size变化 */ void onVideoSizeChanged(MediaPlayer mp, int width, int height); /** * 缓存更新变化 * * @param percent 缓冲百分比 */ void onBufferingUpdate(MediaPlayer mp, int percent); /** * 播放完成 */ void onCompletion(MediaPlayer mp); /** * 视频错误 * @param what 错误类型 * @param extra 特殊错误码 */ void onError(MediaPlayer mp, int what, int extra); /** * 视频加载状态变化 * * @param isShow 是否显示loading */ void onLoadingChanged(boolean isShow); /** * 视频状态变化 */ void onStateChanged(int curState); }
当然了,各位使用上述两个回调时,必须先实现、再使用,当然也可以基于它拓展了!
四.自定义view
关于播放器中涉及到的、需要自定义的view主要有手势调节进度、音量、亮度时的弹框、控制器界面、错误界面。当然我们的JsPlayer视频播放器也是一自定义view,其手势控制也封装了一个view,这些我们稍后会详细介绍。
JsVideoProgressOverlay: 调节进度 框
JsVideoSystemOverlay: 调节音量、亮度 框
JsVideoErrorView: 错误界面
JsVideoControllerView: 控制器
我的思路是这样的:将错误界面JsVideoErrorView再封装到控制器中JsVideoControllerView,这样便于在出错时的处理;而调节进度等弹框、控制器,当然还有SurfaceView,加载中等,它们会一同封装到视频播放器JsPlayer的自定义View中。
JsVideoProgressOverlay
因为字数的限制,详细的请点击阅读原文看我的博客和Github。
JsVideoErrorView
因为字数的限制,详细的请点击阅读原文看我的博客和Github。
JsVideoControllerView
因为字数的限制,详细的请点击阅读原文看我的博客和Github。
五.MediaPlayer封装
主要封装了
openVideo:播放视频,处理各回调
start:开始播放
pause:暂停播放
seekTo:定位到
reset:视频重置
stop:停止播放
isPlaying:是否正在播放
getDuration:获取总时长
getCurrentPosition:获取当前进度
getBufferPercentage:获取缓冲进度等
定义了视频播放的所用状态值常量
//出错状态 public static final int STATE_ERROR = -1; //通常状态 public static final int STATE_IDLE = 0; //视频正在准备 public static final int STATE_PREPARING = 1; //视频已经准备好 public static final int STATE_PREPARED = 2; //视频正在播放 public static final int STATE_PLAYING = 3; //视频暂停 public static final int STATE_PAUSED = 4; //视频播放完成 public static final int STATE_PLAYBACK_COMPLETED = 5;
// 播放核心使用MediaPlayer private MediaPlayer player; // 当前状态 private int curState = STATE_IDLE; // 当前缓冲进度 private int currentBufferPercentage; // *视频路径 private String path; // 播放监听 private OnPlayerCallback onPlayerListener; // 播放视频承载的view private SurfaceHolder surfaceHolder;
封装了视频播放状态的判断
public boolean isInPlaybackState() { return (player != null && curState != STATE_ERROR && curState != STATE_IDLE && curState != STATE_PREPARING); }
此方法会在其他的所有方法执行之前判断,如果返回false,则不进行开始播放、重新播放、拖动定位等操作。
同时这些操作执行完后都会更新当前播放状态,防止视频不能播的情况下操作报错。如
/** * 开始播放 */ public void start() { if (isInPlaybackState()) { player.start(); setCurrentState(STATE_PLAYING); } }
在openVideo中:
public void openVideo() { if (path == null || surfaceHolder == null) { return; } reset(); player = new MediaPlayer(); // 准备好的监听 player.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { //因为后面播放时要判断当前视频状态,所以在此一定要先将状态改变为STATE_PREPARED //即已经准备好,否则在第一次打开视频时无法自动播放 setCurrentState(STATE_PREPARED); if (onPlayerListener != null) { onPlayerListener.onPrepared(mp); } } }); // 缓冲监听 player.setOnBufferingUpdateListener(new MediaPlayer.OnBufferingUpdateListener() { @Override public void onBufferingUpdate(MediaPlayer mp, int percent) { if (onPlayerListener != null) { onPlayerListener.onBufferingUpdate(mp, percent); } currentBufferPercentage = percent; } }); // 播放完成监听 player.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { @Override public void onCompletion(MediaPlayer mp) { if (onPlayerListener != null) { onPlayerListener.onCompletion(mp); } setCurrentState(STATE_PLAYBACK_COMPLETED); } }); // 信息监听 player.setOnInfoListener(new MediaPlayer.OnInfoListener() { @Override public boolean onInfo(MediaPlayer mp, int what, int extra) { if (onPlayerListener != null) { // 701 加载中 if (what == MediaPlayer.MEDIA_INFO_BUFFERING_START) { onPlayerListener.onLoadingChanged(true); // 702 加载完成 } else if (what == MediaPlayer.MEDIA_INFO_BUFFERING_END) { onPlayerListener.onLoadingChanged(false); } } return false; } }); // 出错监听 player.setOnErrorListener(onErrorListener); // 视频大小切换监听 player.setOnVideoSizeChangedListener(new MediaPlayer.OnVideoSizeChangedListener() { @Override public void onVideoSizeChanged(MediaPlayer mp, int width, int height) { if (onPlayerListener != null) { onPlayerListener.onVideoSizeChanged(mp, width, height); } } }); currentBufferPercentage = 0; try { /** * 在这里开始真正的播放 */ player.setDataSource(path); player.setDisplay(surfaceHolder); player.setAudioStreamType(AudioManager.STREAM_MUSIC); player.setScreenOnWhilePlaying(true); player.prepareAsync(); Log.e(TAG, "openVideo: " ); setCurrentState(STATE_PREPARING); } catch (Exception e) { Log.e(TAG, "openVideo: " + e.toString()); setCurrentState(STATE_ERROR); onErrorListener.onError(player, MediaPlayer.MEDIA_ERROR_UNKNOWN, 0); } }
openVideo就是播放视频的核心方法:新建MediaPlayer对象;将视频播放的各回调交给OnPlayerCallback处理;将外部传进来的SurfaceHolder设置给MediaPlayer,并且prepareAsync之后就可以播放了,当然,不要忘了更新状态!
六.手势控制
说到手势控制,主要是手势控制视频进度,手势控制音量和屏幕亮度。对于手势控制,我自定义了BehaviorView:让其实现GestureDetector的OnGestureListener
public class VideoBehaviorView extends FrameLayout implements GestureDetector.OnGestureListener{
在此view中定义以下方法,实现更新UI,交由子类去复写:
// 更新进度UI,由子类重写 protected void updateSeekUI(int delProgress) { // sub } // 更新音量UI,由子类重写 protected void updateVolumeUI(int max, int progress) { // sub } // 更新亮度UI,由子类重写 protected void updateLightUI(int max, int progress) { // sub }
我的思路是将view的触摸事件全部交给GestureDetector处理:
@Override public boolean onTouchEvent(MotionEvent event) { mGestureDetector.onTouchEvent(event); switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_UP: case MotionEvent.ACTION_OUTSIDE: case MotionEvent.ACTION_CANCEL: endGesture(mFingerBehavior); break; } return true; }
当手指按下时,重置手指行为,获取当前音量、亮度
@Override public boolean onDown(MotionEvent e) { //重置 手指行为 mFingerBehavior = -1; mCurrentVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC); try { mCurrentBrightness = (int) (activity.getWindow().getAttributes().screenBrightness * mMaxBrightness); } catch (Exception exception) { exception.printStackTrace(); } return false; }
在onScroll方法中:
判断决定当前为何种类型手势:左右滑动为调节进度,左半屏上下滑动为调节亮度,右半屏上下滑动为调节音量
/** * 根据手势起始2个点断言 后续行为. 规则如下: * 屏幕切分为: * 1.左右扇形区域为视频进度调节 * 2.上下扇形区域 左半屏亮度调节 后半屏音量调节. */ if (mFingerBehavior < 0) { float moveX = e2.getX() - e1.getX(); float moveY = e2.getY() - e1.getY(); // 如果横向滑动距离大于纵向滑动距离,则认为在调节进度 if (Math.abs(moveX) >= Math.abs(moveY)) mFingerBehavior = FINGER_BEHAVIOR_PROGRESS; // 否则为调节音量或亮度 // 按下位置在屏幕左半边,则是调节亮度 else if (e1.getX() <= width / 2) mFingerBehavior = FINGER_BEHAVIOR_BRIGHTNESS; // 按下位置在屏幕右半边,则是在调节音量 else mFingerBehavior = FINGER_BEHAVIOR_VOLUME; }
手势处理
switch (mFingerBehavior) { case FINGER_BEHAVIOR_PROGRESS: { // 进度变化 // 默认滑动一个屏幕 视频移动八分钟. int delProgress = (int) (1.0f * distanceX / width * 480 * 1000); // 更新快进弹框 updateSeekUI(delProgress); break; } case FINGER_BEHAVIOR_VOLUME: { // 音量变化 float progress = mMaxVolume * (distanceY / height) + mCurrentVolume;   48 31865 48 15287 0 0 800 0 0:00:39 0:00:19 0:00:20 2868; // 控制调节临界范围 if (progress <= 0) progress = 0; if (progress >= mMaxVolume) progress = mMaxVolume; am.setStreamVolume(AudioManager.STREAM_MUSIC, Math.round(progress), 0); updateVolumeUI(mMaxVolume, Math.round(progress)); // 更新当前值 mCurrentVolume = progress; break; } case FINGER_BEHAVIOR_BRIGHTNESS: { // 亮度变化 try { // 如果系统亮度为自动调节,则改为手动调节 if (Settings.System.getInt(getContext().getContentResolver(), Settings.System.SCREEN_BRIGHTNESS_MODE) == Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC) { Settings.System.putInt(getContext().getContentResolver(), Settings.System.SCREEN_BRIGHTNESS_MODE, Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL); } int progress = (int) (mMaxBrightness * (distanceY / height) + mCurrentBrightness); // 控制调节临界范围 if (progress <= 0) progress = 0; if (progress >= mMaxBrightness) progress = mMaxBrightness; Window window = activity.getWindow(); WindowManager.LayoutParams params = window.getAttributes(); params.screenBrightness = progress / (float) mMaxBrightness; window.setAttributes(params); updateLightUI(mMaxBrightness, progress); // 更新当前值 mCurrentBrightness = progress; } catch (Exception e) { e.printStackTrace(); } break; } }
注意:
所有的更新UI操作全部交由子类实现
注意临界范围的控制
控制进度时,百分比最后乘以8分钟,以达到较为适中的用户体验,防止视频时长过大或太小情况下,拖动调节进度变化太过明显或效果不明显。
七.播放器JsPlayer封装
先来看看布局
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <SurfaceView android:id="@+id/video_surface" android:layout_width="match_parent" android:layout_height="match_parent" /> <com.jia.jsplayer.view.JsVideoControllerView android:id="@+id/video_controller" android:layout_width="match_parent" android:layout_height="match_parent"/> <include android:id="@+id/video_loading" layout="@layout/video_controller_loading" /> <com.jia.jsplayer.view.JsVideoSystemOverlay android:id="@+id/video_system_overlay" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:visibility="gone"/> <com.jia.jsplayer.view.JsVideoProgressOverlay android:id="@+id/video_progress_overlay" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:visibility="gone"/> </RelativeLayout>
JsPlayer视频播放器集成自上一步中的VideoBehaviorView,注意复写VideoBehaviorView的更新UI方法。
private SurfaceView surfaceView; private View loadingView; private JsVideoProgressOverlay progressView; private JsVideoSystemOverlay systemView; private JsVideoControllerView mediaController; private JsMediaPlayer mMediaPlayer;
内置封装过得JsMediaPlayer 对象,控制器、和SurfaceView,还有网络状态广播接收器。初始化player,创建JsMediaPlayer对象,设置视频播放回调处理,然后将其设置给ControllerView。
注意:
在准备好的监听中,mediaPlayer执行开始播放,控制器展示,错误界面隐藏。
在播放出错时控制器检查错误类型并展示
在加载状态发生改变时隐藏和展示加载中
private void initPlayer() { mMediaPlayer = new JsMediaPlayer(); // todo 这里可以优化,将这些回调全部暴露出去 mMediaPlayer.setOnPlayerListener(new OnPlayerCallback() { @Override public void onPrepared(MediaPlayer mp) { Log.e(TAG, "onPrepared: " ); mMediaPlayer.start(); mediaController.show(); mediaController.hideErrorView(); } @Override public void onVideoSizeChanged(MediaPlayer mp, int width, int height) { } @Override public void onBufferingUpdate(MediaPlayer mp, int percent) { } @Override public void onCompletion(MediaPlayer mp) { mediaController.updatePausePlay(); } @Override public void onError(MediaPlayer mp, int what, int extra) { mediaController.checkShowError(false); } @Override public void onLoadingChanged(boolean isShow) { if (isShow) showLoading(); else hideLoading(); } @Override public void onStateChanged(int curState) { switch (curState) { case JsMediaPlayer.STATE_IDLE: am.abandonAudioFocus(null); break; case JsMediaPlayer.STATE_PREPARING: am.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); break; } } }); mediaController.setMediaPlayer(mMediaPlayer); }
给SurfaceView设置Callback,返回SurfaceHolder后设置给JsMediaPlayer
surfaceView.getHolder().addCallback(new SurfaceHolder.Callback() { @Override public void surfaceCreated(SurfaceHolder holder) { Log.e(TAG, "surfaceCreated: " ); initWidth = getWidth(); initHeight = getHeight(); if (mMediaPlayer != null) { mMediaPlayer.setSurfaceHolder(holder); } } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { } @Override public void surfaceDestroyed(SurfaceHolder holder) { } });
设置路径,开始播放
public void setPath(final IVideoInfo video) { if (video == null) { return; } mMediaPlayer.reset(); String videoPath = video.getVideoPath(); mediaController.setVideoInfo(video); mMediaPlayer.setPath(videoPath); } public void startPlay(){ mMediaPlayer.openVideo(); }
更新UI
@Override protected void updateSeekUI(int delProgress) { progressView.show(delProgress, mMediaPlayer.getCurrentPosition(), mMediaPlayer.getDuration()); } @Override protected void updateVolumeUI(int max, int progress) { systemView.show(JsVideoSystemOverlay.SystemType.VOLUME, max, progress); } @Override protected void updateLightUI(int max, int progress) { systemView.show(JsVideoSystemOverlay.SystemType.BRIGHTNESS, max, progress); }
当然不会忘记封装播放、暂停、停止、定位、获取总时长等等的基本方法,这里就不再累赘。
八.使用
涉及到播放网路视频,权限少不了
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
播放本地视频别忘了6.0权限适配
布局中添加
player = (JsPlayer) findViewById(R.id.player); player.setOnVideoControlListener(new OnVideoControlListener() { @Override public void onStartPlay() { player.startPlay(); } @Override public void onBack() { } @Override public void onFullScreen() { DisplayUtils.toggleScreenOrientation(MainActivity.this); } @Override public void onRetry(int errorStatus) { } }); player.setPath(new VideoInfo("艺术人生", path));
生命周期绑定
@Override protected void onStop() { super.onStop(); player.onStop(); } @Override protected void onDestroy() { super.onDestroy(); player.onDestroy(); }
全屏操作
@Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } else if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } } @Override public void onBackPressed() { if (!DisplayUtils.isPortrait(this)) { if (!player.isLock()) { DisplayUtils.toggleScreenOrientation(this); } } else { super.onBackPressed(); } }
注意所在Activity在清单文件中应设置
android:configChanges="orientation|keyboardHidden|screenSize"
这样就ok了,播放器封装完美完成!希望对大家有所帮助!
GitHub地址:
https://github.com/shuaijia/JsPlayer